할인조건에 대한 고민- 횟수 할인, 시간 할인, 재결제 할인, 쿠폰 할인, 비율할인

Q.

객체지향 관점에서 할인조건에 대한 고민: 횟수 할인, 시간 할인, 재결제 할인, 쿠폰 할인, 비율할인을 공통인터페이스 "타임슬롯 개별 가격을 계산하라"에 적합하게 선택되어 계산해주는 플랜을 세워보자. 타임슬롯과 Day Availability, AvailableTimeResponseDto를 제공해줄게.

A. Claude Sonet 4

제공해주신 코드를 분석해보니, 타임슬롯 기반의 예약 시스템에서 다양한 할인 조건을 객체지향적으로 설계하는 흥미로운 문제네요. 할인 전략 패턴을 활용한 설계안을 제안해드리겠습니다.

이 설계의 핵심 특징들을 설명드리겠습니다:

🎯 전략 패턴 기반 설계

1. 공통 인터페이스 (IDiscountStrategy)

2. 구체적인 할인 전략들

🏗️ 확장성 고려사항

새로운 할인 전략 추가가 용이:

// 새로운 할인 전략 추가 예시
export class WeatherDiscountStrategy implements IDiscountStrategy {
  readonly strategyId = 'weather_discount';
  readonly priority = 6;
  
  canApply(context: IPricingContext): boolean {
    // 날씨 API 연동하여 비오는 날 할인 적용
    return this.isRainyDay(context.bookingDate);
  }
  
  calculateDiscountedPrice(originalPrice: number, context: IPricingContext): number {
    return Math.floor(originalPrice * 0.8); // 20% 할인
  }
}

🔄 기존 코드와의 통합

MinuteSlotDto 확장:

DayAvailability와의 연동:

// 사용 예시
const pricingService = new TimeSlotPricingService();
const context: IPricingContext = {
  currentSlot: { slotIndex: 36, hour: 6, minute: 0, basePrice: 5000 },
  userHistory: { totalBookingCount: 25, consecutiveBookingDays: 5 },
  currentSession: { selectedSlots: [36, 37, 38], totalDuration: 30 },
  availableCoupons: [/* 쿠폰들 */],
  bookingDate: new Date(),
  currentTime: new Date()
};

const result = pricingService.calculateSlotPrice(context);
// 아침 할인(20%) + 단골 할인(10%) 중 더 유리한 할인 자동 선택

💡 주요 장점

  1. 단일 책임 원칙: 각 할인 전략이 독립적으로 동작
  2. 개방-폐쇄 원칙: 새로운 할인 전략 추가시 기존 코드 수정 불필요
  3. 유연한 할인 적용: 여러 할인 중 가장 유리한 것 자동 선택
  4. 컨텍스트 기반: 사용자 이력, 예약 세션, 시간대 등 종합 고려
  5. UI 친화적: 할인 정보를 구조화하여 프론트엔드 표시 용이

이 설계를 통해 복잡한 할인 로직을 체계적으로 관리하면서도, 새로운 할인 정책 추가나 기존 정책 수정이 용이한 확장 가능한 시스템을 구축할 수 있습니다.

// ═══════════════════════════════════════════════════════════════════════════
// 1. 할인 계산을 위한 컨텍스트 정보
// ═══════════════════════════════════════════════════════════════════════════

export interface IPricingContext {
  /** 현재 계산 중인 타임슬롯 정보 */
  readonly currentSlot: {
    slotIndex: number;
    hour: number;
    minute: number;
    basePrice: number;
  };
  
  /** 사용자 예약 이력 정보 */
  readonly userHistory: {
    totalBookingCount: number;
    lastBookingDate?: Date;
    consecutiveBookingDays: number;
  };
  
  /** 현재 예약 세션 정보 */
  readonly currentSession: {
    selectedSlots: number[]; // 선택된 슬롯 인덱스들
    totalDuration: number;   // 총 예약 시간(분)
  };
  
  /** 쿠폰 정보 */
  readonly availableCoupons: ICoupon[];
  
  /** 예약 날짜/시간 정보 */
  readonly bookingDate: Date;
  readonly currentTime: Date;
}

export interface ICoupon {
  id: string;
  type: 'percentage' | 'fixed' | 'time_based';
  value: number;
  minBookingAmount?: number;
  validUntil: Date;
  usageLimit?: number;
  usedCount: number;
}

// ═══════════════════════════════════════════════════════════════════════════
// 2. 할인 전략 인터페이스
// ═══════════════════════════════════════════════════════════════════════════

export interface IDiscountStrategy {
  /** 할인 전략의 고유 식별자 */
  readonly strategyId: string;
  
  /** 할인 전략의 우선순위 (낮을수록 먼저 적용) */
  readonly priority: number;
  
  /** 이 할인이 현재 컨텍스트에 적용 가능한지 확인 */
  canApply(context: IPricingContext): boolean;
  
  /** 할인된 가격 계산 */
  calculateDiscountedPrice(originalPrice: number, context: IPricingContext): number;
  
  /** 할인 정보 반환 (UI 표시용) */
  getDiscountInfo(context: IPricingContext): IDiscountInfo;
}

export interface IDiscountInfo {
  strategyId: string;
  discountName: string;
  discountAmount: number;
  discountRate?: number;
  description: string;
}

// ═══════════════════════════════════════════════════════════════════════════
// 3. 구체적인 할인 전략 구현들
// ═══════════════════════════════════════════════════════════════════════════

/**
 * 횟수 할인: 예약 횟수에 따른 누적 할인
 */
export class BookingCountDiscountStrategy implements IDiscountStrategy {
  readonly strategyId = 'booking_count_discount';
  readonly priority = 1;
  
  private readonly discountTiers = [
    { minCount: 10, discountRate: 0.05 }, // 10회 이상 5% 할인
    { minCount: 20, discountRate: 0.10 }, // 20회 이상 10% 할인
    { minCount: 50, discountRate: 0.15 }, // 50회 이상 15% 할인
  ];
  
  canApply(context: IPricingContext): boolean {
    return context.userHistory.totalBookingCount >= this.discountTiers[0].minCount;
  }
  
  calculateDiscountedPrice(originalPrice: number, context: IPricingContext): number {
    const tier = this.discountTiers
      .reverse()
      .find(tier => context.userHistory.totalBookingCount >= tier.minCount);
    
    if (!tier) return originalPrice;
    
    return Math.floor(originalPrice * (1 - tier.discountRate));
  }
  
  getDiscountInfo(context: IPricingContext): IDiscountInfo {
    const tier = this.discountTiers
      .reverse()
      .find(tier => context.userHistory.totalBookingCount >= tier.minCount);
    
    return {
      strategyId: this.strategyId,
      discountName: '단골 할인',
      discountAmount: Math.floor(context.currentSlot.basePrice * tier!.discountRate),
      discountRate: tier!.discountRate,
      description: `${context.userHistory.totalBookingCount}회 예약 달성 (${tier!.discountRate * 100}% 할인)`
    };
  }
}

/**
 * 시간대 할인: 특정 시간대에 따른 할인
 */
export class TimeBasedDiscountStrategy implements IDiscountStrategy {
  readonly strategyId = 'time_based_discount';
  readonly priority = 2;
  
  private readonly timeDiscounts = [
    { startHour: 6, endHour: 9, discountRate: 0.20, name: '아침 할인' },
    { startHour: 14, endHour: 17, discountRate: 0.15, name: '오후 할인' },
    { startHour: 21, endHour: 23, discountRate: 0.25, name: '늦은 시간 할인' },
  ];
  
  canApply(context: IPricingContext): boolean {
    return this.timeDiscounts.some(discount => 
      context.currentSlot.hour >= discount.startHour && 
      context.currentSlot.hour < discount.endHour
    );
  }
  
  calculateDiscountedPrice(originalPrice: number, context: IPricingContext): number {
    const discount = this.timeDiscounts.find(discount => 
      context.currentSlot.hour >= discount.startHour && 
      context.currentSlot.hour < discount.endHour
    );
    
    if (!discount) return originalPrice;
    
    return Math.floor(originalPrice * (1 - discount.discountRate));
  }
  
  getDiscountInfo(context: IPricingContext): IDiscountInfo {
    const discount = this.timeDiscounts.find(discount => 
      context.currentSlot.hour >= discount.startHour && 
      context.currentSlot.hour < discount.endHour
    )!;
    
    return {
      strategyId: this.strategyId,
      discountName: discount.name,
      discountAmount: Math.floor(context.currentSlot.basePrice * discount.discountRate),
      discountRate: discount.discountRate,
      description: `${discount.startHour}시-${discount.endHour}시 시간대 할인`
    };
  }
}

/**
 * 재결제 할인: 연속 예약일에 따른 할인
 */
export class ConsecutiveBookingDiscountStrategy implements IDiscountStrategy {
  readonly strategyId = 'consecutive_booking_discount';
  readonly priority = 3;
  
  canApply(context: IPricingContext): boolean {
    return context.userHistory.consecutiveBookingDays >= 3;
  }
  
  calculateDiscountedPrice(originalPrice: number, context: IPricingContext): number {
    const days = context.userHistory.consecutiveBookingDays;
    const discountRate = Math.min(days * 0.02, 0.20); // 최대 20% 할인
    
    return Math.floor(originalPrice * (1 - discountRate));
  }
  
  getDiscountInfo(context: IPricingContext): IDiscountInfo {
    const days = context.userHistory.consecutiveBookingDays;
    const discountRate = Math.min(days * 0.02, 0.20);
    
    return {
      strategyId: this.strategyId,
      discountName: '연속 예약 할인',
      discountAmount: Math.floor(context.currentSlot.basePrice * discountRate),
      discountRate: discountRate,
      description: `${days}일 연속 예약 (${discountRate * 100}% 할인)`
    };
  }
}

/**
 * 쿠폰 할인: 보유 쿠폰을 활용한 할인
 */
export class CouponDiscountStrategy implements IDiscountStrategy {
  readonly strategyId = 'coupon_discount';
  readonly priority = 4;
  
  canApply(context: IPricingContext): boolean {
    return context.availableCoupons.some(coupon => 
      coupon.validUntil > context.currentTime &&
      coupon.usedCount < (coupon.usageLimit ?? Infinity) &&
      (coupon.minBookingAmount ?? 0) <= context.currentSlot.basePrice
    );
  }
  
  calculateDiscountedPrice(originalPrice: number, context: IPricingContext): number {
    const bestCoupon = this.getBestCoupon(originalPrice, context);
    if (!bestCoupon) return originalPrice;
    
    switch (bestCoupon.type) {
      case 'percentage':
        return Math.floor(originalPrice * (1 - bestCoupon.value / 100));
      case 'fixed':
        return Math.max(0, originalPrice - bestCoupon.value);
      case 'time_based':
        // 시간 기반 쿠폰 (예: 30분 무료)
        const freeMinutes = bestCoupon.value;
        const pricePerMinute = originalPrice / 10; // 10분 슬롯 기준
        return Math.max(0, originalPrice - (freeMinutes * pricePerMinute));
      default:
        return originalPrice;
    }
  }
  
  private getBestCoupon(originalPrice: number, context: IPricingContext): ICoupon | null {
    const validCoupons = context.availableCoupons.filter(coupon => 
      coupon.validUntil > context.currentTime &&
      coupon.usedCount < (coupon.usageLimit ?? Infinity) &&
      (coupon.minBookingAmount ?? 0) <= originalPrice
    );
    
    if (validCoupons.length === 0) return null;
    
    // 가장 할인 효과가 큰 쿠폰 선택
    return validCoupons.reduce((best, current) => {
      const bestDiscount = this.calculateCouponDiscount(originalPrice, best);
      const currentDiscount = this.calculateCouponDiscount(originalPrice, current);
      return currentDiscount > bestDiscount ? current : best;
    });
  }
  
  private calculateCouponDiscount(originalPrice: number, coupon: ICoupon): number {
    switch (coupon.type) {
      case 'percentage':
        return originalPrice * (coupon.value / 100);
      case 'fixed':
        return Math.min(coupon.value, originalPrice);
      case 'time_based':
        const freeMinutes = coupon.value;
        const pricePerMinute = originalPrice / 10;
        return Math.min(freeMinutes * pricePerMinute, originalPrice);
      default:
        return 0;
    }
  }
  
  getDiscountInfo(context: IPricingContext): IDiscountInfo {
    const bestCoupon = this.getBestCoupon(context.currentSlot.basePrice, context)!;
    const discountAmount = this.calculateCouponDiscount(context.currentSlot.basePrice, bestCoupon);
    
    return {
      strategyId: this.strategyId,
      discountName: `쿠폰 할인 (${bestCoupon.id})`,
      discountAmount,
      description: `${bestCoupon.type} 쿠폰 적용`
    };
  }
}

/**
 * 장시간 예약 할인: 한 번에 긴 시간 예약시 할인
 */
export class LongDurationDiscountStrategy implements IDiscountStrategy {
  readonly strategyId = 'long_duration_discount';
  readonly priority = 5;
  
  private readonly durationTiers = [
    { minMinutes: 60, discountRate: 0.05 },   // 1시간 이상 5% 할인
    { minMinutes: 120, discountRate: 0.10 },  // 2시간 이상 10% 할인
    { minMinutes: 180, discountRate: 0.15 },  // 3시간 이상 15% 할인
  ];
  
  canApply(context: IPricingContext): boolean {
    return context.currentSession.totalDuration >= this.durationTiers[0].minMinutes;
  }
  
  calculateDiscountedPrice(originalPrice: number, context: IPricingContext): number {
    const tier = this.durationTiers
      .reverse()
      .find(tier => context.currentSession.totalDuration >= tier.minMinutes);
    
    if (!tier) return originalPrice;
    
    return Math.floor(originalPrice * (1 - tier.discountRate));
  }
  
  getDiscountInfo(context: IPricingContext): IDiscountInfo {
    const tier = this.durationTiers
      .reverse()
      .find(tier => context.currentSession.totalDuration >= tier.minMinutes)!;
    
    return {
      strategyId: this.strategyId,
      discountName: '장시간 예약 할인',
      discountAmount: Math.floor(context.currentSlot.basePrice * tier.discountRate),
      discountRate: tier.discountRate,
      description: `${context.currentSession.totalDuration}분 예약 (${tier.discountRate * 100}% 할인)`
    };
  }
}

// ═══════════════════════════════════════════════════════════════════════════
// 4. 할인 계산 서비스 (Strategy Pattern의 Context)
// ═══════════════════════════════════════════════════════════════════════════

export class TimeSlotPricingService {
  private readonly discountStrategies: IDiscountStrategy[] = [];
  
  constructor() {
    // 할인 전략들을 우선순위 순으로 등록
    this.discountStrategies = [
      new BookingCountDiscountStrategy(),
      new TimeBasedDiscountStrategy(),
      new ConsecutiveBookingDiscountStrategy(),
      new CouponDiscountStrategy(),
      new LongDurationDiscountStrategy(),
    ].sort((a, b) => a.priority - b.priority);
  }
  
  /**
   * 특정 타임슬롯의 최종 가격을 계산합니다.
   * 여러 할인이 적용 가능한 경우, 가장 유리한 할인만 적용합니다.
   */
  calculateSlotPrice(context: IPricingContext): ISlotPricingResult {
    const applicableDiscounts = this.discountStrategies
      .filter(strategy => strategy.canApply(context))
      .map(strategy => ({
        strategy,
        discountedPrice: strategy.calculateDiscountedPrice(context.currentSlot.basePrice, context),
        discountInfo: strategy.getDiscountInfo(context)
      }));
    
    if (applicableDiscounts.length === 0) {
      return {
        originalPrice: context.currentSlot.basePrice,
        finalPrice: context.currentSlot.basePrice,
        appliedDiscount: null,
        availableDiscounts: []
      };
    }
    
    // 가장 할인 효과가 큰 전략 선택
    const bestDiscount = applicableDiscounts.reduce((best, current) => 
      current.discountedPrice < best.discountedPrice ? current : best
    );
    
    return {
      originalPrice: context.currentSlot.basePrice,
      finalPrice: bestDiscount.discountedPrice,
      appliedDiscount: bestDiscount.discountInfo,
      availableDiscounts: applicableDiscounts.map(d => d.discountInfo)
    };
  }
  
  /**
   * 전체 예약 세션의 총 가격을 계산합니다.
   */
  calculateSessionTotalPrice(
    baseSlotPrices: number[],
    selectedSlotIndices: number[],
    userContext: Omit<IPricingContext, 'currentSlot'>
  ): ISessionPricingResult {
    const slotResults: ISlotPricingResult[] = [];
    let totalOriginalPrice = 0;
    let totalFinalPrice = 0;
    
    selectedSlotIndices.forEach((slotIndex, index) => {
      const slotTime = DayAvailability.slotIndexToTime(slotIndex);
      const context: IPricingContext = {
        ...userContext,
        currentSlot: {
          slotIndex,
          hour: slotTime.h,
          minute: slotTime.m,
          basePrice: baseSlotPrices[slotIndex]
        }
      };
      
      const result = this.calculateSlotPrice(context);
      slotResults.push(result);
      totalOriginalPrice += result.originalPrice;
      totalFinalPrice += result.finalPrice;
    });
    
    return {
      slotResults,
      totalOriginalPrice,
      totalFinalPrice,
      totalSavings: totalOriginalPrice - totalFinalPrice
    };
  }
}

// ═══════════════════════════════════════════════════════════════════════════
// 5. 결과 타입 정의
// ═══════════════════════════════════════════════════════════════════════════

export interface ISlotPricingResult {
  originalPrice: number;
  finalPrice: number;
  appliedDiscount: IDiscountInfo | null;
  availableDiscounts: IDiscountInfo[];
}

export interface ISessionPricingResult {
  slotResults: ISlotPricingResult[];
  totalOriginalPrice: number;
  totalFinalPrice: number;
  totalSavings: number;
}

// ═══════════════════════════════════════════════════════════════════════════
// 6. MinuteSlotDto 확장 (기존 코드와의 통합)
// ═══════════════════════════════════════════════════════════════════════════

/**
 * 기존 MinuteSlotDto를 가격 계산 기능과 함께 확장
 */
export class EnhancedMinuteSlotDto extends MinuteSlotDto {
  @NumberProperty({ description: '할인 적용 후 최종 가격', optional: true })
  finalPrice?: number;
  
  @ObjectProperty({ description: '적용된 할인 정보', optional: true })
  appliedDiscount?: IDiscountInfo;
  
  @ArrayProperty({ 
    type: Object, 
    description: '사용 가능한 모든 할인 정보', 
    optional: true 
  })
  availableDiscounts?: IDiscountInfo[];
  
  /**
   * 가격 계산 서비스를 사용하여 할인 가격을 계산하고 설정
   */
  calculatePrice(
    pricingService: TimeSlotPricingService,
    context: IPricingContext
  ): void {
    if (!this.isAvailable || !this.price) return;
    
    const result = pricingService.calculateSlotPrice({
      ...context,
      currentSlot: {
        ...context.currentSlot,
        basePrice: this.price
      }
    });
    
    this.finalPrice = result.finalPrice;
    this.appliedDiscount = result.appliedDiscount;
    this.availableDiscounts = result.availableDiscounts;
  }
}